Skip to content

The stranded-attach warning repeats where the run ends (#614) - #625

Open
philcunliffe wants to merge 10 commits into
masterfrom
fix/issue-614
Open

The stranded-attach warning repeats where the run ends (#614)#625
philcunliffe wants to merge 10 commits into
masterfrom
fix/issue-614

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Correction (the description below was rewritten to match what merges).
Three things moved under this PR while it was open:

  • It is no longer stacked on Unpicking an attached client is named, not silently left broken (#604) #608. The original body opened with a base
    note saying Unpicking an attached client is named, not silently left broken (#604) #608 was unmerged and this branch sat on fix/issue-604. Unpicking an attached client is named, not silently left broken (#604) #608
    merged on 2026-08-05; this branch is based on origin/master and the diff
    below is this change alone.
  • The gate is measured, not inferred. The original described the repeat as
    firing when "the first look ran (attended, uncancelled, not a dry run) or the
    pathway is team". That condition shipped and was then replaced twice under
    review. What merges gates on firstLookResult?.wrote === true and nothing
    else: runWizardFirstLook counts its own writes and reports them, so the
    caller reads the fact instead of deducing it. firstLookRan is too wide,
    shown is too narrow, and pathway === 'team' is wrong in a way worth
    spelling out (below).
  • The design record is LLP 0230,
    not LLP 0188.
    master landed a different 0188-* document
    (enrolled-default-sync-with-client-optout) while this branch was open, so
    this doc was renumbered. The two anchor sets are disjoint and the sweep is
    complete: every @ref to #repeat-at-the-end, #when, #shorter and
    #narration-stays-last says 0230, and LLP 0185's Extended-by: header and
    inline note both point at 0230.

Root cause

runPickerFinale prints the stranded-attach warning and returns. What happens
next depends entirely on which entry point called it, and nothing in the code
accounted for that:

  • runPickerWalkthrough writes its short run summary and stops. The warning is
    within a handful of lines of the end.
  • runInitWizard writes the run summary, then runWizardFirstLook (a block of
    real query output, sized by LLP 0198 §wizard-sections), then on the team path
    the privacy narration. Nothing pauses between them, so on a real terminal the
    warning has scrolled away by the time hyp init finishes, and a user who does
    not scroll back never reads it.

writeWalkthroughRunSummary never read finaleSummary.attachedNotConfigured,
which is what the issue reports, but that is the symptom rather than the cause:
the cause is that the finale's placement is correct for the daemon restart and
wrong for the end of the run.

It is buried worst exactly where it matters most. On a managed host hyp status's
mirror diagnostic (client_attached_not_configured) is gated to hosts with no
central layer (LLP 0185 #status-backstop), so the finale's print is the only
signal a wizard-attached client the reconfigure run left unpicked ever gets. A
joined host has no backstop to scroll back to.

The fix

The finale's print stays exactly where LLP 0185 put it, after the config
write and before the daemon restart. runInitWizard additionally writes a short
repeat after the first look and before the privacy narration, naming the same
clients and the same hyp detach --client <name> lines.

  • writeAttachedNotConfiguredReminder (new export,
    src/core/cli/walkthrough.js) is the compact form: names plus one repair
    command each, with the same dry-run tag rule. The finale's own
    writeAttachedNotConfiguredWarning, which explains the consequence in full,
    stays private and unchanged.
  • It goes before narratePrivacyIfTeamPath, so the privacy narration is
    still the wizard's last words (LLP 0135, LLP 0100/0101). A short narration is a
    reminder that survives on one screen; a screenful of query output is not, and
    that is the difference this turns on.
  • runPickerWalkthrough does not repeat. It writes nothing substantial after the
    finale, so it keeps its single print.

The gate: measured output, not an inferred branch

runWizardFirstLook now reports whether it put anything on the screen
(src/core/cli/wizard/first_look.js). The writable its body writes through is a
counter in front of the caller's, and the function has a single exit that
attaches the count:

export async function runWizardFirstLook({ runner, stdout: target, ... }) {
  let wrote = false
  const stdout = { write(chunk) { wrote = true; return target.write(chunk) } }
  const outcome = await withSpan(...)
  return { ...outcome, wrote }
}

Three properties fall out of that shape:

  • the value is measured at the writable, not derived from which branch returned,
    so no branch can print without being counted;
  • stdout is the only writable in scope inside the body, so a branch added later
    writes through the counter by construction rather than by remembering to;
  • there is one exit, so a new branch cannot return an outcome with no wrote on
    it.

wrote is set before delegating to the caller's write: a write that throws
part-way (EPIPE on a closed pipe) may already have emitted, and the safe error is
an extra reminder rather than a lost one.

The type splits accordingly: FirstLookOutcome keeps the branch shapes
(shown, reason, row counts) exactly as they were, and
FirstLookResult = FirstLookOutcome & { wrote: boolean }. firstLookHadRows
takes the outcome half only, since wrote says nothing about whether the cache
has rows.

The gate in src/core/cli/wizard/index.js is then
stranded.length > 0 && firstLookResult?.wrote === true. Why each of the
alternatives is wrong, which is the part that took three attempts:

  • firstLookRan is too wide. The step is documented to degrade rather than
    fail a finished install (LLP 0135 #first-look), and an unregistered dataset, an
    unreadable cache or a render that throws leave it having run and written
    nothing. Nothing was buried, so nothing should be repeated.
  • shown is too narrow. The slow skip renders no block and still writes two
    lines saying so, which buries the finale's print exactly as a full render
    would.
  • pathway === 'team' is wrong rather than merely imprecise. A pathway is
    only ever resolved on an interactive run, so a team run that is neither
    cancelled nor a dry run has already run the first look. || pathway === 'team'
    would therefore widen the gate to exactly the runs where the first look did
    not run (cancelled at the backfill consent, or a dry run), every one of which
    wrote nothing in between, so the repeat would land a few lines under the print
    it repeats.

firstLookRan still gates whether the step runs at all; only the repeat's
condition reads wrote.

The double-print hazard, and how it is avoided

Round 2 of #608's review flagged that echoing attachedNotConfigured from
writeWalkthroughRunSummary would print the warning twice within a few lines of
itself on the runPickerWalkthrough path, where the summary follows the finale
with nothing in between. That function is shared by both entry points, so the
naive echo is wrong twice over: it double-prints on one path and still lands
before the first look on the other, which is the complaint it was meant to fix.

So writeWalkthroughRunSummary is untouched. The repeat belongs to the caller
that buried the first print
, because only the caller knows whether anything
came between. No path prints the warning twice on one screen.

This is a print-contract change to an Accepted LLP, so it is recorded as an
extension rather than an edit: LLP 0230 (new, Accepted) with an
Extended-by: forward-ref and an inline note added to LLP 0185
§status-backstop. LLP 0185's decision itself is unchanged.

Ground truth

test/core/cli/wizard/index.test.js carries five gate tests, four of which pin
that nothing changes:

  • runInitWizard: an attended run repeats the stranded-attach warning after the first look (the feature).
  • runInitWizard: an attended run whose first look skips slowly still repeats the stranded-attach warning - team pathway, attachedNotConfigured: ['codex'], a
    first-look runner that never resolves, driving the real runWizardFirstLook
    through the real orchestrator into the reason: 'slow' branch. It asserts the
    block did not render, that the repeat ran anyway, and that it still sits
    between the skip's lines and the privacy narration. This is the test that
    discriminates the shipped gate: reverting it to
    firstLookResult?.shown === true fails this one and only this one.
  • runInitWizard: a scripted run does not repeat the stranded-attach warning.
  • runInitWizard: a run cancelled at the finale does not repeat the stranded-attach warning.
  • runInitWizard: an attended run whose first look skips itself does not repeat the stranded-attach warning (the silent no-dataset / error degradations).

test/core/cli/wizard/first-look.test.js pins wrote per branch rather than
leaving it implicit: true for a full render, a partial render and the slow
skip; false for no-dataset, no-runner and the two error skips, with
stdout.text() asserted empty alongside. The EPIPE test pins wrote: true, for
the reason above.

test/core/walkthrough-unpicked-attached-clients.test.js's existing the finale names a still-attached client the new config no longer collects now counts
occurrences: exactly one hyp detach --client codex and one Still attached, no longer collected on the runPickerWalkthrough path. That assertion fails under
the naive shared-summary echo.

The wizard exposes firstLookBudgetMs beside the existing firstLook runner
override, as a test seam, so an orchestrator-level test can reach the slow branch
without waiting out the real budget.

At head 8cddf08: npm test `# tests 4089 / # pass 4088 / # fail 0 /

skipped 1; npm run typecheck` clean; CI green on all nine checks.

Deferred to #779

Two comment-only leftovers, substance unaffected: two comments added by this PR
still say "sixty lines" of first-look output where LLP 0230 was correctly
softened to cite LLP 0198 §wizard-sections for the sizing
(src/core/cli/walkthrough.js, test/core/cli/wizard/index.test.js); and
writeAttachedNotConfiguredReminder's dryRun argument is provably false at
its only call site, since a measured write implies the first look ran, which
implies not a dry run. It is kept as a parameter deliberately, carried from
review round 2, and recorded on #779 for findability rather than for action.

Fixes #614

test and others added 5 commits August 4, 2026 21:13
Re-running `hyp init` and unchecking a previously attached client
regenerates a config without that client's adapter while the client's own
settings still route through the HypAware gateway. The finale only
attaches, and the action reconciler's reverse lane undoes only
config-named (org/central) keys, so a wizard attach on the local layer is
never reversed: capture stops and the client's requests can fail. Picking
only OpenTelemetry composes no gateway at all, and every still-attached
client points at a dead port after the finale's restart.

The finale now names the clients it left stranded (attach marker on disk,
not picked, enabled by neither the written config nor the central layer)
and prints the `hyp detach --client <name>` that clears each. It does not
detach: rewriting a client's settings file is not what unchecking a
picker row asked for, and that call is the maintainer's to make (LLP
0185). `hyp status` gains the matching `client_attached_not_configured`
diagnostic as the after-the-fact backstop, gated to solo hosts because on
a joined host that shape is a reconciler pass that has not run yet.

The org/central attach markers are untouched throughout.

Co-Authored-By: Claude <noreply@anthropic.com>
…604)

Three defects in the #604 fix, found in neutral review.

An unreadable local config emptied the active-plugin set for a reason that
has nothing to do with what the operator enabled, and the new
`client_attached_not_configured` branch read that as "nothing enables this
adapter": `hyp status` answered a parse failure with a `hyp detach` for
every attached client, stacked on top of the `config_unreadable` error that
is the actual repair. The branch now stands down when the local layer is
present but did not parse.

`findAttachedNotConfiguredClients` counted a plugin entry with
`enabled: false` as configured, while `hyp status` builds the same set from
`activePlugins`, which skips it. A switched-off adapter collects exactly as
little as an absent one, so the two surfaces disagreed about the same file.

LLP 0185 records both gates, and the forward-refs the corpus convention
wants land on the docs it extends: LLP 0180 (the finale's attach lane) and
LLP 0086 (the client drift diagnostic family). The status test's `@ref`
carried an empty gloss; it now says what it tests.

Tests: an unreadable local config draws no detach advice; a disabled plugin
entry strands its client; and the central-layer suppression LLP 0185 #scope
promises for the finale, which had no coverage on the walkthrough side.
Both new gates fail without the src change.

npm test: 3386 tests / 3385 pass / 0 fail / 1 skipped. npm run typecheck
clean. Smokes status_diagnostics and walkthrough_picker_to_first_query ok.

Co-Authored-By: Claude <noreply@anthropic.com>
…ad unfiltered (#604)

The status gate stands down on a local layer that is present but does not
parse, and deliberately does not stand down when there is no config at all:
nothing collects then, so a marker still on disk really is stranded. That
boundary (`errorKind !== 'config_missing'`, not `!ok`) had no test, so
widening the gate would have dropped the case silently. Adds one.

`readCentralPluginNames` is deliberately not filtered by `enabled`, unlike the
local set the caller builds: LLP 0185 #scope settles the org lane on the name
alone, and filtering would hand the operator a detach for an org-owned client.
Says so at the read, and scopes #not-configured-means-not-active to the local
layer so the two sections cannot be read against each other.

Co-Authored-By: Claude <noreply@anthropic.com>
The finale names the clients this run left attached but no longer collects,
before the daemon restart (LLP 0185). `runInitWizard` then writes the run
summary, a first look that is roughly sixty lines of real query output, and on
the team path the privacy narration, none of which pause, so on a real terminal
the warning is gone by the time setup ends. It is buried worst where it is the
only signal there is: on a managed host `hyp status`'s mirror diagnostic is
gated to hosts with no central layer.

The wizard now repeats it, short, after the first look and before the privacy
narration. The repeat is the caller's rather than the shared
`writeWalkthroughRunSummary`'s, because only the caller knows whether anything
came between: `runPickerWalkthrough` writes a short summary and stops, so it
keeps its single print instead of showing the same paragraph twice within a few
lines of itself.

LLP 0188 records the print contract and carries the forward-ref on LLP 0185.

Co-Authored-By: Claude <noreply@anthropic.com>
The gate was `firstLookRan || pathway === 'team'`. A `pathway` is only ever
resolved on an interactive run, so a team run that is neither cancelled nor a
dry run already has `firstLookRan`; the disjunct added exactly the runs where
the first look did *not* run. Those are the runs that wrote nothing between
the finale and here, so on a team run cancelled at the backfill consent the
repeat landed three lines under the finale's own full warning, which is the
same double print the shared run summary was rejected for.

The gate is now `firstLookRan` alone. LLP 0188 #when says so and says why,
and a new test pins the cancelled team run (fails on the old gate).

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral review, round 1 of 2

Verdict: approve after the one fix below, which is pushed. Reviewed as
git diff origin/fix/issue-604...ce11b67 (the stacked base, not master). The
diagnosis is right, the placement is right, and the choice not to touch
writeWalkthroughRunSummary is right. One condition in the gate was wrong,
and it was wrong in exactly the direction the design doc rules out.

Finding 1 (medium, fixed): the || pathway === 'team' disjunct fires only where the repeat must not

src/core/cli/wizard/index.js:234 gated the repeat on
firstLookRan || pathway === 'team'. Expand it:

  • firstLookRan === interactive && !cancelled && opts.finale?.dryRun !== true
  • pathway is assigned only inside if (interactive) (index.js:71-131), so
    pathway !== undefined implies interactive === true.

Therefore pathway === 'team' adds a run to the gate only when
cancelled || dryRun, that is, only when the first look did not run. Those
are precisely the runs where nothing was written between the finale and the
repeat, so the disjunct did not widen the fix, it produced the double print
LLP 0188 says no path produces.

Reachable case: a team/managed run cancelled at the backfill consent prompt.
summary.cancelled is set inside runFinaleBackfill
(src/core/cli/walkthrough.js:1238), which runs before the finale computes
and prints attachedNotConfigured (walkthrough.js:1024-1032), so the finale's
full six-line warning is printed on a cancelled run too. The wizard then wrote
the run summary (3 lines) and repeated the same two lines under it. Probed
against the real orchestrator with a cancelled team finale, stdout was:

✓ Wrote /tmp/x/config.json
Still attached, no longer collected: codex
Their requests can start failing until you run:
  hyp detach --client codex

(the second block is the repeat; the finale's own print sits directly above it
in production, where the finale is not stubbed). The privacy narration does not
reliably follow either: narratePrivacyIfTeamPath returns early when there is
no first-sync deadline on disk (index.js:375), so the repeat can be the last
thing on screen, four lines under its own original.

The dryRun half is unreachable from the CLI today (--dry-run goes through
runPickerInit, which always sets picks, so interactive is false and no
pathway is resolved) but it fails the same way for any other caller.

Fixed in e21952a: the gate is firstLookRan alone, with the derivation in
a comment and a @ref LLP 0188#when [constrained-by]. LLP 0188 §when is
rewritten to state the actual condition and why the team disjunct is wrong
(that section is new in this PR, so nothing settled was edited). New test
runInitWizard: a run cancelled at the finale does not repeat the stranded-attach warning pins it; verified it fails on the old gate
(not ok 19) and passes on the new one.

Verified independently

  • Regression strength. Reverted the whole source hunk
    (git checkout origin/fix/issue-604 -- src/) and ran the two test files:
    not ok 17 - runInitWizard: an attended run repeats the stranded-attach warning after the first look, # tests 25 / # pass 24 / # fail 1. Exactly
    the claimed failure, and exactly one: the scripted-run test and the two new
    exactly-once counters in
    test/core/walkthrough-unpicked-attached-clients.test.js:96-98 do pass on
    both sides, so they pin the double-print hazard rather than the feature, as
    claimed.
  • Entry points and early exits. runPickerWalkthrough never reaches the
    new code. In runInitWizard, every exit before the finale (gate quit/status,
    fork quit, picked.cancelled) returns with finaleSummary undefined, so
    stranded is [] and no bare repeat is possible. opts.finale absent,
    same. After the fix, no path prints the warning twice.
  • The solo case. An attended, uncancelled, non-dry local run has
    firstLookRan === true, so it repeats: correct, the first look buried the
    original there too. A solo run that skipped the first look also skipped
    everything else, so the finale print is still on screen and no reminder is
    right.
  • Ordering (LLP 0135 / 0100-0101). The call sits after the first-look block
    and before narratePrivacyIfTeamPath (index.js:242-245); the narration is
    still the last words. LLP 0135 §first-look does say the ~60-line block runs
    and that the narration "is written after the block and stays the last thing
    on screen", so the cited claim is true.
  • Managed-host claim. True. src/core/daemon/status.js:650 gates
    client_attached_not_configured on !configured && probe.attached && !hasCentral && !localConfigUnreadable, annotated
    @ref LLP 0185#status-backstop. On a joined host there is no backstop, so
    the wizard's print is the only signal.
  • LLP 0188. Number 0188 is unclaimed elsewhere (0186/0187 are taken by
    integration/reconciler-refused-marker; nothing else holds 0188). All three
    relative links resolve, both anchors used by @refs
    (#repeat-at-the-end, #when) exist, Systems: matches 0185's exactly, and
    Status: Accepted matches the corpus pattern for a decision landing with its
    code. The Extended-by: addition to 0185 is a forward-ref plus an inline
    note in §status-backstop; it does not alter what 0185 settled, which
    CLAUDE.md explicitly permits.
  • Style. No em dashes, no semicolons, no @typedef, no inline
    import('...') types in the diff or in my fix.

Residual findings

  1. (nit, not fixed) writeAttachedNotConfiguredReminder's dryRun
    argument is now provably false at its only call site, since firstLookRan
    excludes dry runs. Left as the honest source of the value rather than a
    hardcoded false, so a future caller does not inherit a wrong tag. LLP 0188
    §shorter's "both prints share the dry-run tag rule" is therefore a rule with
    no reachable instance today.
  2. (nit, not fixed) LLP 0188 introduces a **Extends:** header field that
    no other doc in the corpus uses (0185 lists the docs it extends under
    Related:). Symmetric with the established Extended-by: and nothing
    parses these headers, so it is harmless, but it is a new convention landed
    without a note.
  3. (informational) The PR body's "The wizard ... calls it only when its
    closing sequence actually ran: the first look ran ... or the pathway is
    team
    " and the "Ground truth" counts now describe the pre-fix commit. The
    body was left untouched per the review protocol; the code and LLP 0188 are
    the current record.
  4. (informational) The feature test stubs finaleRunner, so the finale's
    own print never appears on the wizard path in tests. The exactly-once
    property is pinned only on the runPickerWalkthrough path
    (walkthrough-unpicked-attached-clients.test.js) and, on the wizard path,
    by the two negative tests. A test that drives the real finale end to end
    through the wizard would close that last gap; it is not cheap, and the
    negative tests cover the reachable regression.

Numbers

  • npm test at the reviewed head ce11b67: `# tests 3389 / # pass 3388 /

    fail 0 / # skipped 1` (matches the PR body).

  • npm test after the fix (e21952a): `# tests 3390 / # pass 3389 /

    fail 0 / # skipped 1`.

  • npm run typecheck: clean at both.

Pushed to fix/issue-614 as e21952a. Base branch fix/issue-604 untouched.

Base automatically changed from fix/issue-604 to master August 5, 2026 01:36
…mpting

`firstLookRan` says the closing block was attempted. It is documented to
degrade to a silent skip rather than fail a finished install (LLP 0135
#first-look), so an unregistered `ai_gateway_messages` dataset, an unreadable
cache, or a render that throws leaves an attended run that wrote nothing
between the finale's warning and the closing repeat. Probed against the real
orchestrator with no overview dataset: stdout was the config-write line and
the repeat, with the finale's own six-line warning directly above it in
production. That is the same-screen double print LLP 0188 says no path
produces, reached through the first look's designed degradation instead of
through the pathway disjunct round 1 removed.

Gate on the result: `runWizardFirstLook` already returns `shown`, which is
exactly "the block reached the screen". LLP 0188 §when restated to match (the
condition is the closing sequence having *written* something), and the
`**Extends:**` header folded into `Related:`, which is how the corpus's other
167 docs name what they extend.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral review, round 2 of 2

Verdict: approve after the one fix below, which is pushed (e1bfbc3).
Reviewed as git diff origin/fix/issue-604...e21952a (the stacked base, not
master). Round 1's fix is correct as far as it goes and its reasoning holds up
under re-derivation. It removed one route to the same-screen double print;
there is a second route, through the first look's designed degradation
rather than through the pathway disjunct, and it is closed here.

Finding 1 (low, fixed): firstLookRan means "attempted", and the repeat needs "printed"

src/core/cli/wizard/index.js:243 gated the repeat on firstLookRan, which is
interactive && !cancelled && opts.finale?.dryRun !== true: the conditions
under which the first look is called. But runWizardFirstLook
(src/core/cli/wizard/first_look.js:126-229) is documented to never fail a
finished install (@ref LLP 0135#first-look) and returns { shown: false }
having written nothing to stdout on two of its three skip reasons:

  • no-dataset (first_look.js:135): no runner, or the registry has no
    ai_gateway_messages. overviewRunnerFromCtx yields this whenever the boot
    context has no query capability or the gateway plugin is not active.
  • error (first_look.js:224): the whole block is inside one try precisely so
    that an unreadable cache, an unforeseen row shape, or a stream that throws on
    write ends quietly. The JSDoc names the unreadable cache explicitly.

(The third, slow, writes two lines, so it is the same hazard in weaker form.)

On any of those, an attended run has firstLookRan === true and has written
nothing at all between the finale's warning and the repeat. Probed against the
real orchestrator (real runInitWizard, real runWizardFirstLook, only the
finale and the pick stubbed, no overview dataset), stdout was:

✓ Wrote /tmp/x/config.json

Still attached, no longer collected: codex
Their requests can start failing until you run:
  hyp detach --client codex

In production the finale's own six-line warning sits directly above that
✓ Wrote line, so the user reads the same warning twice inside eight lines.
That is the outcome LLP 0188 §repeat-at-the-end asserts no path produces and
§shorter says "reads as a bug rather than a reminder", and it is the same
defect class round 1 fixed, reached by a different route.

Fixed in e1bfbc3: runWizardFirstLook already returns exactly the fact
the gate needs, so the wizard now captures it (firstLookShown = look.shown)
and gates on that. The failure direction is safe either way (an extra reminder
beats a lost one), which is why this is low and not medium, but the doc's claim
is now true rather than nearly true. LLP 0188 §when is restated to say the
condition is the closing sequence having written something, and why that is
narrower than having run it. New test
runInitWizard: an attended run whose first look skips itself does not repeat the stranded-attach warning; verified it fails on the round-1 gate
(not ok 20, # fail 1) and passes on the new one.

Finding 2 (nit, fixed): the **Extends:** header field

Round 1's carried item 2. Checked the corpus: across 167 docs the header field
histogram is Type 170, Status 170, Related 170, Systems 166, Date
162, Author 146, Generated-by 35, Extended-by 14, and Extends 1,
which is this PR. The established shape is asymmetric on purpose: the extending
doc names the extended one under Related: (LLP 0086 -> 0044, LLP 0105 ->
0070, LLP 0093 -> 0080 all do this), and the extended doc carries
Extended-by:. Nothing parses it either way (/llp-list reads only Status,
Systems, Type), so this was harmless, but a one-off field in a corpus this
size is a convention landed without a decision. Folded into Related: with the
Extended-by relationship named inline; LLP 0185's forward-ref is unchanged
and still carries the semantics.

Round 1's fix, re-derived independently

  • firstLookRan is genuinely right on the paths round 1 named. Confirmed
    summary.cancelled is set in runFinaleBackfill
    (src/core/cli/walkthrough.js:1238) and the attachedNotConfigured compute
    and print happen after it (walkthrough.js:1022-1031), so the finale's full
    warning is printed on a cancelled run, which is what makes the removed
    || pathway === 'team' disjunct a double print rather than a widening.
  • No path silently loses the reminder. Scripted --yes, --from-file,
    preset: interactive false, nothing written between the finale and the end,
    finale print is the last substantive output. --dry-run: same, and the
    config that strands them was not written. Cancelled: finale print, then
    hyp init: cancelled (stderr), a three-line summary, and at most the
    narration. Every exit before the finale returns with finaleSummary
    undefined, so stranded is []. opts.finale absent: same. After finding 1,
    the degraded first look joins that list, correctly.
  • Nothing widened by the new gate. shown: true includes the partial
    (deadline-expired) render, which is still tens of lines, so the ordinary
    reconfigure case this PR exists for is unaffected.
  • Exactly-once still holds on the picker path. 27/27 in
    test/core/cli/wizard/index.test.js +
    test/core/walkthrough-unpicked-attached-clients.test.js, including the two
    occurrence counters round 1 checked.

The other two carried items, determined

  • Item 1 (the dryRun argument is provably false): leave it. Still true
    after this round, and still the right call. writeAttachedNotConfiguredReminder
    is an export whose signature should describe the print, not the one call site
    that currently reaches it; hardcoding false would hand a future caller a
    silently wrong tag. LLP 0188 §shorter's shared-tag rule stays a rule with no
    reachable instance, which is a property of the call graph, not a defect.
  • Item 4 (the feature test stubs finaleRunner): not a real gap. The
    finale's own print lives in runPickerFinale, and exactly-once for it is
    pinned against the real function on the picker path
    (walkthrough-unpicked-attached-clients.test.js:92-98, which builds real
    descriptors, real attach markers and a real home). The wizard calls that same
    function unmodified and adds exactly one conditional print, and that print is
    now pinned in both directions by four tests (one positive, three negative:
    scripted, cancelled, skipped-first-look). An end-to-end wizard test driving
    the real finale would re-stage the picker test's entire fixture to re-prove a
    property that composes; the cost is not worth the coverage it adds.

Numbers

  • npm test at e1bfbc3: `# tests 3391 / # pass 3390 / # fail 0 /

    skipped 1`.

  • npm run typecheck: clean.
  • Targeted: node --test test/core/cli/wizard/index.test.js test/core/walkthrough-unpicked-attached-clients.test.js -> `# tests 27 /

    pass 27 / # fail 0`.

  • Style: no em dashes, no semicolons, no @typedef, no inline import('...')
    types in the delta or in my fix. All four LLP 0188 anchors and every relative
    link resolve.

Residual findings

  1. (nit, not fixed) writeAttachedNotConfiguredReminder's dryRun
    argument is unreachable-true at its only call site, as above. Deliberate;
    no action recommended.
  2. (informational) The PR body still describes the original commit: it
    states the gate as "the first look ran ... or the pathway is team" and
    quotes pre-fix test counts. Bodies are not edited by review. The code, LLP
    0188 and this thread are the current record.
  3. (informational) This PR remains stacked on fix/issue-604 (Unpicking an attached client is named, not silently left broken (#604) #608). It
    must not be merged before Unpicking an attached client is named, not silently left broken (#604) #608, and the base branch was not touched.

Pushed to fix/issue-614 as e1bfbc3. Base branch fix/issue-604 untouched.

PR #608 was squash-merged as 6ab0d99, so this branch's copies of its
commits conflicted with the same content under new SHAs. Every conflicted
hunk that was #608's takes master; the branch keeps only its own work
(LLP 0188, writeAttachedNotConfiguredReminder, the wizard's firstLookShown
gated repeat, and its tests).

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe philcunliffe added the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Aug 5, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral triage on bc57742 (fix/issue-614), after the review fix-loop hit its 2-round bound.

What I was checking

The task was to re-verify, with the same skepticism that caught the first two
gate bugs, whether firstLookShown (round 2's fix) is finally the right
condition for the closing repeat, and to confirm the post-#608-merge conflict
resolution didn't resurrect either of the two earlier broken conditions.

Conflict resolution: clean

git diff origin/master...HEAD --stat against current master
(db33f04) touches only llp/0185-* (forward-ref), the new llp/0188-*,
src/core/cli/walkthrough.js (writeAttachedNotConfiguredReminder),
src/core/cli/wizard/index.js (the gate), and the two test files. Nothing of
#608's own work is in the diff (#608 is already in master as 6ab0d99). The
merge commit message correctly states the resolution rule ("every conflicted
hunk that was #608's takes master"), and a full-text search of
wizard/index.js on this branch finds exactly one firstLookShown gate, no
surviving pathway === 'team' disjunct, and no reversion to the round-1
firstLookRan condition. Round 1 and round 2's fixes both survived the merge
intact.

firstLookShown: still wrong, on a path neither round exercised

I read src/core/cli/wizard/first_look.js directly rather than the diff.
runWizardFirstLook has four exit points:

  • no dataset -> {shown: false}, nothing written. Correct.
  • an exception during the query/render -> {shown: false}, nothing written
    (in every path I found). Correct.
  • success -> {shown: true}, writes the block. Correct.
  • the deadline expires with nothing renderable yet (expired && !hasRenderableOverview(rows), first_look.js:167-179) -> writes two lines
    to stdout ("Skipped the first look: summarizing this much history would
    hold up setup." / "Run hyp query overview to see it.") and returns
    {shown: false, reason: 'slow'}
    .

That fourth branch is not hypothetical: it is the documented purpose of
FIRST_LOOK_BUDGET_MS (OVERVIEW_TIME_BUDGET_MS + headroom, 8s total), whose
own doc comment calls out "a pathological day, a disk that stalls" as the
scenario it exists for. It is exercised today by
test/core/cli/wizard/first-look.test.js ("a slow cache skips within budget
and says what to run"), which already asserts both the shown: false result
and the "Skipped the first look" text landing on stdout in the same call.
Neither prior round's fix, nor this PR's own new tests, exercise this branch
through the orchestrator: test/core/cli/wizard/index.test.js's "first look
skips itself" case only covers the no-dataset skip (hasDataset: () => false, which correctly writes nothing), not the slow skip.

I reproduced it directly against this branch's runInitWizard (team pathway,
attachedNotConfigured: ['codex'], a first-look runner that never resolves
inside the 8s budget):

--- elapsed ms: 8027
--- contains "Skipped the first look": true
--- contains "Still attached, no longer collected": false
--- contains "hyp detach --client codex": false
--- contains privacy narration "Nothing has been uploaded yet": true

firstLookShown is false (the 'slow' branch), so the closing repeat's
guard (stranded.length > 0 && firstLookShown, wizard/index.js:263) does
not fire, even though two lines were written by the first look and the
7-line privacy narration follows after. The finale's original warning
(before the daemon restart) is left as the only place hyp detach --client codex ever appears, buried under both blocks with nothing to bring it back
into view.

This is the same failure mode issue #614 exists to fix, reached through a
different door than either prior round checked. The code comments at
wizard/index.js:220-226 assert "a first look that skipped itself wrote
nothing" as the justification for reading shown instead of ran — that
claim is true for the no-dataset and error skips but false for the slow skip,
which is the actual bug: firstLookShown conflates "rendered the block" with
"wrote nothing," and only the former is what it measures.

User-visible impact: on a slow/cold cache during hyp init (large
backfill just landed, disk under load), a user who unpicked a previously
wizard-attached client never sees the warning that its requests can start
silently failing until hyp detach is run — worst on a managed host, where
(per LLP 0185 §status-backstop, cited in this PR's own body) hyp status's
mirror diagnostic is gated off entirely, so this print is the only signal
that ever existed. I judge that a production defect (a warning about an
active failure mode that silently never reaches the user), not a preference.

Non-blocking findings, carried over from round 2 (unchanged by my check)

  1. Nit, deliberate, confirmed still true on this head: dryRun passed to
    writeAttachedNotConfiguredReminder (wizard/index.js:267) is provably
    false at its only call site, because the call is gated by
    firstLookShown which requires firstLookRan, which requires
    opts.finale?.dryRun !== true. Round 2's call to leave it (the export's
    signature should describe the print, not its one caller) stands; this
    does not change with the fix below.
  2. Informational: the PR body describes a pre-fix, stacked-on-Unpicking an attached client is named, not silently left broken (#604) #608 state
    of the world; review protocol does not edit bodies.
  3. Informational: the feature test stubs finaleRunner rather than
    asserting end-to-end against the real finale print; round 2 judged this
    adequate because the finale's own exactly-once print is separately pinned
    against the real function on the picker path. I have no new objection to
    that reasoning, but it's moot until the blocker above is resolved, since
    the new tests also don't cover the 'slow' skip branch at all.

What would unstick this

The fix is narrow: change the closing-repeat gate (or runWizardFirstLook's
contract) so that "wrote something" is measured directly rather than assumed
from shown. Two shapes that would work:

  • Broaden FirstLookResult so reason: 'slow' counts as shown-enough for the
    repeat's purposes (it did put ink on the screen), or
  • Have runWizardFirstLook return whether it wrote anything, independent of
    whether the block itself rendered, and gate the repeat on that instead.

Either needs a new orchestrator-level test exercising the slow-skip branch
with attachedNotConfigured set, alongside the three existing gate tests.

I have not modified any code — this comment is diagnostic only. Reply on this
thread to unstick: confirm the fix shape, or tell me the "warn or don't"
tradeoff here is intentional and I'm misjudging severity.

I did not merge, did not mark ready, and did not push to master.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Decision on the fix shape: the second option - measure "wrote something" directly.

Have runWizardFirstLook return whether it wrote anything to stdout, independent of whether the summary block itself rendered, and gate the closing repeat on that instead of firstLookShown. The gate should measure the thing it actually cares about (did text push the earlier warning out of view), not infer it from shown - that inference is exactly what has now broken three times across the no-dataset, error, and slow-skip paths.

Include the new orchestrator-level test exercising the slow-skip branch (reason: 'slow') with attachedNotConfigured set, alongside the three existing gate tests. Also update the now-false code comment at wizard/index.js:220-226 ("a first look that skipped itself wrote nothing") to match the real contract.

@philcunliffe philcunliffe removed the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Aug 14, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Re-engaging with your guidance - taking the second option, measure "wrote something" directly: runWizardFirstLook returns whether it wrote anything to stdout, independent of whether the summary block rendered, and the closing repeat gates on that rather than on firstLookShown. Noted the reasoning that inferring from shown is what broke three times across the no-dataset, error and slow-skip paths.

Also taking the two specifics: a new orchestrator-level test for the slow-skip branch (reason: 'slow' with attachedNotConfigured set) alongside the three existing gate tests, and correcting the now-false comment at wizard/index.js:220-226.

The stuck label is removed. Neutral re-runs the rung at the current head on its next tick.

Maintainer decision on the fix shape: measure "wrote something" directly.

`runWizardFirstLook` now writes through a counter and returns `wrote`
beside `shown`, so the step reports for itself whether it put text on the
screen. The wizard's closing stranded-attach repeat gates on that.

`shown` answers "did the block render", which is not the question the
gate asks. The `slow` skip renders no block and still writes two lines
saying so, and those lines bury the finale's warning exactly as a full
render would: on a managed host, where LLP 0185 #status-backstop gates
`hyp status`'s mirror diagnostic off, that print was the only signal the
user was ever going to get. Inferring the gate from a neighbouring fact
has now broken on the no-dataset, error and slow paths in turn; a
measured value closes the class, because a skip reason added later is
counted by the same writable.

- `FirstLookResult` = `FirstLookOutcome & { wrote: boolean }`, attached
  at a single exit so no branch can omit it.
- New orchestrator test: an attended run whose first look skips slowly
  still repeats the warning. It fails on the `shown` gate (`not ok 22`,
  `# fail 1`) and passes on the measured one. `firstLookBudgetMs` is a
  test seam so it does not wait out the real 8s budget.
- The first-look unit tests now pin `wrote` per branch: true for the
  render, the partial render and the slow skip, false for no-dataset and
  the error skips.
- The comment claiming "a first look that skipped itself wrote nothing"
  is replaced by the real contract, and LLP 0188 #when is restated to
  say the condition is measured output.

`npm test`: 3413 tests, 3412 pass, 0 fail, 1 skipped. `npm run
typecheck`: clean.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Applied your decision on d50d688: the second option, measure "wrote something" directly.

What changed

runWizardFirstLook reports its own output (src/core/cli/wizard/first_look.js:137-151, 259-261). The stdout the body writes through is now a counter in front of the caller's writable, and the function has a single exit that attaches the count:

export async function runWizardFirstLook({ runner, stdout: target, ... }) {
  let wrote = false
  const stdout = { write(chunk) { wrote = true; return target.write(chunk) } }
  const outcome = await withSpan(...)
  return { ...outcome, wrote }
}

Three properties fall out of that shape, and they are what make this different from the last three fixes:

  • The value is measured at the writable, not derived from which branch returned. No branch can print without being counted.
  • stdout is the only writable in scope inside the body, so a branch added later writes through the counter by construction rather than by remembering to.
  • There is one exit, so a new branch cannot return an outcome with no wrote on it. FirstLookResult is now FirstLookOutcome & { wrote: boolean } (src/core/cli/wizard/types.d.ts:303-321), and the split leaves the branch shapes (shown, reason, row counts) exactly as they were.

wrote is set before delegating to the caller's write, not after: a write that throws part-way may have emitted, and an extra reminder is the safe error.

The gate reads it (src/core/cli/wizard/index.js:230, 239, 270): let firstLookWrote = false, firstLookWrote = look.wrote, if (stranded.length > 0 && firstLookWrote). firstLookRan still gates whether the step runs at all; only the repeat's condition moved.

The false comment is gone (src/core/cli/wizard/index.js:221-229 and 252-266). It claimed "a first look that skipped itself wrote nothing", which was true of the no-dataset and error skips and false of the slow one. It now states the contract as it is: the step degrades in two different ways, one silent and one that prints two lines, and runWizardFirstLook measures which happened so the orchestrator does not have to deduce it. The derivation of why pathway === 'team' and firstLookRan are both wrong is kept, with look.shown added to the list and its reason.

LLP 0188 §when is restated to match: the condition is the closing sequence having written something, measured rather than inferred, with "the first look ran" named as too wide and "the block rendered" as too narrow. A Consequences bullet records that FirstLookResult carries wrote, because that fact belongs to the step rather than to its caller.

The new test and its evidence

test/core/cli/wizard/index.test.js: runInitWizard: an attended run whose first look skips slowly still repeats the stranded-attach warning. Team pathway, attachedNotConfigured: ['codex'], a first-look runner that never resolves, driving the real runWizardFirstLook through the real orchestrator into the reason: 'slow' branch. It asserts the block did not render (Skipped the first look, no First look at what HypAware has recorded), that the repeat ran anyway, and that it still sits between the skip's lines and the privacy narration. The three existing gate tests are unchanged and still there: scripted, cancelled-at-finale, silently-skipped.

Before and after, same test file, only the gate expression differing:

# firstLookWrote = look.shown  (the round-2 gate)
not ok 22 - runInitWizard: an attended run whose first look skips slowly still repeats the stranded-attach warning
# tests 24 / # pass 23 / # fail 1

# firstLookWrote = look.wrote  (this change)
# tests 24 / # pass 24 / # fail 0

One failure, and it is the new test. The wizard exposes firstLookBudgetMs as a test seam beside the existing firstLook runner override, so the orchestrator-level test does not have to wait out the real 8s budget to reach the branch.

The first-look unit tests now pin wrote per branch rather than leaving it implicit: true for a full render, a partial render and the slow skip; false for no-dataset, no-runner and the two error skips (with stdout.text() asserted empty alongside). The EPIPE test pins wrote: true with the reason stated, since the write was attempted.

Numbers

  • npm test: # tests 3413 / # pass 3412 / # fail 0 / # skipped 1.
  • npm run typecheck: clean.
  • Targeted: node --test test/core/cli/wizard/index.test.js test/core/walkthrough-unpicked-attached-clients.test.js -> # tests 29 / # pass 29 / # fail 0, including the two exactly-once counters on the picker path.
  • Style: no em dashes, no semicolons, no @typedef, no inline import('...') types in the delta. Both LLP 0188 anchors used by @refs resolve.

The carried nit is unchanged and still deliberate: writeAttachedNotConfiguredReminder's dryRun argument is provably false at its only call site, because firstLookWrote still implies firstLookRan.

Pushed to fix/issue-614 as d50d688.

test added 2 commits August 14, 2026 20:52
Conflicts resolved keeping both intents. The closing repeat still gates on
what the first look *measured* it wrote (`firstLookResult.wrote`), never on
the inferred `shown`, and `runWizardFirstLook` keeps its single exit.

- src/core/cli/wizard/index.js: import block takes both sides' symbols
  (`writeAttachedNotConfiguredReminder` from this branch, the express-gate
  and TUI imports from master). `LOGIN_ORG_SELECTION_MESSAGE` is dropped:
  master's login lane returns its outcome (LLP 0179), so nothing reads it.
  The first look now has one result variable, `firstLookResult`, which both
  the closing repeat (`.wrote`) and master's first ask (`firstLookHadRows`)
  read.
- src/core/cli/wizard/types.d.ts: `FirstLookOutcome`/`FirstLookResult` split
  kept alongside master's `FirstAskLauncher`/`FirstAskResult`/`WizardSyncNow*`;
  `firstLookBudgetMs` kept alongside `firstAsk`/`syncNow`.
- `firstLookHadRows` takes `FirstLookOutcome`, not `FirstLookResult`: it
  answers from what the step found, and `wrote` says nothing about rows.
- test/core/cli/wizard/first-look.test.js: master's two-section assertion
  (LLP 0198 #wizard-sections) with this branch's `wrote: true`.
- test/core/cli/wizard/index.test.js: both new suites kept whole.

LLP number collision: master landed LLP 0188 (enrolled default sync), so this
branch's decision is renumbered 0188 -> 0230, the first free number above the
highest claimed on any remote branch (LLP 0156 #renumber). Every inbound
reference is swept: LLP 0185's Extended-by lines, and the `#when` /
`#repeat-at-the-end` @refs in walkthrough.js, wizard/first_look.js,
wizard/index.js, wizard/types.d.ts and the three test files.
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral triage on 8cddf08 (fix/issue-614): ship. Path B, follow-up in #779, nothing blocking.

The maintainer's decision, verified applied at this head

All verification below was done independently in a fresh worktree at 8cddf0881979ab2bc9c9477e48d270e0a432d718, not taken from the ack-applied report.

  • The gate reads measured output. src/core/cli/wizard/index.js:705 gates the closing repeat on stranded.length > 0 && firstLookResult?.wrote === true. No look.shown, no firstLookRan, no pathway === 'team' anywhere in the gate. The ?. covers the not-ran case (result undefined, no repeat), so the collapsed variable is behaviorally identical to the reviewed let firstLookWrote = false shape.
  • The single exit holds. runWizardFirstLook (src/core/cli/wizard/first_look.js:164-291) destructures the caller's writable as target, wraps it in a counting shim that is the only stdout in scope in the body (all four interior writes at lines 226, 243, 261, 271 go through it), and has exactly one function-level return: return { ...outcome, wrote }. wrote is set before delegating, so a throwing write still counts. A future branch cannot print uncounted or return without wrote.
  • All four gate tests pass, and the new one discriminates. runInitWizard gate tests at this head: attended-repeat, scripted-no-repeat, cancelled-no-repeat, silent-skip-no-repeat, plus the new slow-skip test ("an attended run whose first look skips slowly still repeats the stranded-attach warning"), which drives the real runWizardFirstLook through the real orchestrator into reason: 'slow' via the firstLookBudgetMs seam. I reverted the gate expression to firstLookResult?.shown === true locally and reran: exactly one failure, and it is the slow-skip test (# tests 60 / # pass 59 / # fail 1). Restored, all pass.
  • The false comment is gone. The old "a first look that skipped itself wrote nothing" claim at wizard/index.js:220-226 is replaced by a derivation (index.js:655-665 and 681-702) that names both degradation modes, states why firstLookRan, shown, and pathway === 'team' are each wrong, and defers to the measured value. FirstLookOutcome/FirstLookResult split is in src/core/cli/wizard/types.d.ts:529-546, and firstLookHadRows correctly takes the outcome half only.
  • Numbers. npm test: # tests 4089 / # pass 4088 / # fail 0 / # skipped 1. npm run typecheck: clean. Targeted wizard plus picker files: all pass including the two exactly-once counters. CI is green on all nine checks at this exact head, and the PR reports MERGEABLE.

The two flagged judgement calls

  1. The collapsed variable (firstLookResult?.wrote instead of a distinct firstLookWrote): not a blocker, and the right resolution. The reviewed property was "the gate reads the value the step measured, through a single exit", and that survives verbatim. Master's firstLookHadRows (LLP 0198 #empty-cache) genuinely needs the whole result, so keeping two variables would have stored the same object twice. The merge commit records the reasoning. Preference-level rename, substance preserved.
  2. The three softened prose edits in LLP 0230: acceptable as landed. The doc first enters master's history with this PR, so it is in-flight, not an edit to an Accepted record. Restoring "about seventy" / "roughly sixty" would land numbers master's LLP 0198 #wizard-sections already invalidated; the softened text ("tens of lines", "a block of real query output ... sized by LLP 0198 §wizard-sections") cites the doc that now owns the sizing, which is the durable form. The numbers should not come back.

The one inconsistency the sweep left behind: two comments added by this PR still say "sixty lines" (src/core/cli/walkthrough.js:1861, test/core/cli/wizard/index.test.js:851) while the LLP was correctly softened. Comment-only, substance unaffected, deferred to #779.

The renumbering (0188 to 0230), verified

  • No duplicate numbers. A four-digit-prefix duplicate scan over llp/ (tombstones included) finds none; llp/0188-enrolled-default-sync-with-client-optout.decision.md (master's) and llp/0230-stranded-attach-warning-repeats-at-the-end.decision.md (this PR's) coexist, and the duplicate-numbers CI check passes.
  • The disjoint-anchors claim is true. Master's 0188 anchors: rule, never-silent, locked, opt-out, migration, enforcement-scope, no-retroactive-ship. This doc's: repeat-at-the-end, when, shorter, narration-stays-last. No overlap, so a targeted sweep was safe.
  • The sweep is complete. Zero references to 0188#when, 0188#repeat-at-the-end, or 0188#shorter remain anywhere; every ref to this doc's anchors says 0230 (first_look.js, index.js, walkthrough.js, types.d.ts, all three test files); LLP 0185's Extended-by header and inline note both say 0230 with a resolving relative link. Every surviving LLP 0188 mention refers to master's enrolled-default-sync doc and its own anchors.

Everything else

  • Diff vs master touches exactly the nine expected files; nothing of Unpicking an attached client is named, not silently left broken (#604) #608 (already in master) leaks in. The PR body still describes the original stacked commit and the pre-fix gate, which the review rounds already marked informational; the code, LLP 0230, and this thread are the current record.
  • Carried nit (the provably false dryRun argument): confirmed still true and still the deliberate call from round 2; recorded in Follow-up: deferred review findings from PR #625 #779 for findability, no action.
  • Style: no em dashes and no semicolon line-endings in the delta; all four LLP 0230 anchors used by @refs resolve.

Follow-up issue: #779. Not marking ready and not merging, per the rung's bounds.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-up: deferred review findings from PR #608

1 participant